#!/usr/bin/env python3
import argparse
import os
import pyautogui
import subprocess
import dearpygui.dearpygui as dpg
import multiprocessing
import uuid
import signal
import yaml  # type: ignore
from openpilot.common.swaglog import cloudlog
from openpilot.common.basedir import BASEDIR
from openpilot.tools.jotpluggler.data import DataManager
from openpilot.tools.jotpluggler.datatree import DataTree
from openpilot.tools.jotpluggler.layout import LayoutManager

DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19"


class WorkerManager:
  def __init__(self, max_workers=None):
    self.pool = multiprocessing.Pool(max_workers or min(4, multiprocessing.cpu_count()), initializer=WorkerManager.worker_initializer)
    self.active_tasks = {}

  def submit_task(self, func, args_list, callback=None, task_id=None):
    task_id = task_id or str(uuid.uuid4())

    if task_id in self.active_tasks:
      try:
        self.active_tasks[task_id].terminate()
      except Exception:
        pass

    def handle_success(result):
      self.active_tasks.pop(task_id, None)
      if callback:
        try:
          callback(result)
        except Exception as e:
          print(f"Callback for task {task_id} failed: {e}")

    def handle_error(error):
      self.active_tasks.pop(task_id, None)
      print(f"Task {task_id} failed: {error}")

    async_result = self.pool.starmap_async(func, args_list, callback=handle_success, error_callback=handle_error)
    self.active_tasks[task_id] = async_result
    return task_id

  @staticmethod
  def worker_initializer():
    signal.signal(signal.SIGINT, signal.SIG_IGN)

  def shutdown(self):
    for task in self.active_tasks.values():
      try:
        task.terminate()
      except Exception:
        pass
    self.pool.terminate()
    self.pool.join()


class PlaybackManager:
  def __init__(self):
    self.is_playing = False
    self.current_time_s = 0.0
    self.duration_s = 0.0
    self.num_segments = 0

    self.x_axis_bounds = (0.0, 0.0)  # (min_time, max_time)
    self.x_axis_observers = []  # callbacks for x-axis changes
    self._updating_x_axis = False

  def set_route_duration(self, duration: float):
    self.duration_s = duration
    self.seek(min(self.current_time_s, duration))

  def toggle_play_pause(self):
    if not self.is_playing and self.current_time_s >= self.duration_s:
      self.seek(0.0)
    self.is_playing = not self.is_playing
    texture_tag = "pause_texture" if self.is_playing else "play_texture"
    dpg.configure_item("play_pause_button", texture_tag=texture_tag)

  def seek(self, time_s: float):
    self.current_time_s = max(0.0, min(time_s, self.duration_s))

  def update_time(self, delta_t: float):
    if self.is_playing:
      self.current_time_s = min(self.current_time_s + delta_t, self.duration_s)
      if self.current_time_s >= self.duration_s:
        self.is_playing = False
        dpg.configure_item("play_pause_button", texture_tag="play_texture")
    return self.current_time_s

  def set_x_axis_bounds(self, min_time: float, max_time: float, source_panel=None):
    if self._updating_x_axis:
      return

    new_bounds = (min_time, max_time)
    if new_bounds == self.x_axis_bounds:
      return

    self.x_axis_bounds = new_bounds
    self._updating_x_axis = True  # prevent recursive updates

    try:
      for callback in self.x_axis_observers:
        try:
          callback(min_time, max_time, source_panel)
        except Exception as e:
          print(f"Error in x-axis sync callback: {e}")
    finally:
      self._updating_x_axis = False

  def add_x_axis_observer(self, callback):
    if callback not in self.x_axis_observers:
      self.x_axis_observers.append(callback)

  def remove_x_axis_observer(self, callback):
    if callback in self.x_axis_observers:
      self.x_axis_observers.remove(callback)

class MainController:
  def __init__(self, scale: float = 1.0):
    self.scale = scale
    self.data_manager = DataManager()
    self.playback_manager = PlaybackManager()
    self.worker_manager = WorkerManager()
    self._create_global_themes()
    self.data_tree = DataTree(self.data_manager, self.playback_manager)
    self.layout_manager = LayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale)
    self.data_manager.add_observer(self.on_data_loaded)
    self._total_segments = 0

  def _create_global_themes(self):
    with dpg.theme(tag="line_theme"):
      with dpg.theme_component(dpg.mvLineSeries):
        scaled_thickness = max(1.0, self.scale)
        dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)

    with dpg.theme(tag="timeline_theme"):
      with dpg.theme_component(dpg.mvInfLineSeries):
        scaled_thickness = max(1.0, self.scale)
        dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
        dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots)

    for tag, color in (("active_tab_theme", (37, 37, 38, 255)), ("inactive_tab_theme", (70, 70, 75, 255))):
      with dpg.theme(tag=tag):
        for cmp, target in ((dpg.mvChildWindow, dpg.mvThemeCol_ChildBg), (dpg.mvInputText, dpg.mvThemeCol_FrameBg), (dpg.mvImageButton, dpg.mvThemeCol_Button)):
          with dpg.theme_component(cmp):
            dpg.add_theme_color(target, color)

    with dpg.theme(tag="tab_bar_theme"):
      with dpg.theme_component(dpg.mvChildWindow):
        dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (51, 51, 55, 255))

  def on_data_loaded(self, data: dict):
    duration = data.get('duration', 0.0)
    self.playback_manager.set_route_duration(duration)

    if data.get('metadata_loaded'):
      self.playback_manager.num_segments = data.get('total_segments', 0)
      self._total_segments = data.get('total_segments', 0)
      dpg.set_value("load_status", f"Loading... 0/{self._total_segments} segments processed")
    elif data.get('reset'):
      self.playback_manager.current_time_s = 0.0
      self.playback_manager.duration_s = 0.0
      self.playback_manager.is_playing = False
      self._total_segments = 0
      dpg.set_value("load_status", "Loading...")
      dpg.set_value("timeline_slider", 0.0)
      dpg.configure_item("timeline_slider", max_value=0.0)
      dpg.configure_item("play_pause_button", texture_tag="play_texture")
      dpg.configure_item("load_button", enabled=True)
    elif data.get('loading_complete'):
      num_paths = len(self.data_manager.get_all_paths())
      dpg.set_value("load_status", f"Loaded {num_paths} data paths")
      dpg.configure_item("load_button", enabled=True)
    elif data.get('segment_added'):
      segment_count = data.get('segment_count', 0)
      dpg.set_value("load_status", f"Loading... {segment_count}/{self._total_segments} segments processed")

    dpg.configure_item("timeline_slider", max_value=duration)

  def save_layout_to_yaml(self, filepath: str):
    layout_dict = self.layout_manager.to_dict()
    with open(filepath, 'w') as f:
      yaml.dump(layout_dict, f, default_flow_style=False, sort_keys=False)

  def load_layout_from_yaml(self, filepath: str):
    with open(filepath) as f:
      layout_dict = yaml.safe_load(f)
    self.layout_manager.clear_and_load_from_dict(layout_dict)
    self.layout_manager.create_ui("main_plot_area")

  def save_layout_dialog(self):
    if dpg.does_item_exist("save_layout_dialog"):
      dpg.delete_item("save_layout_dialog")
    with dpg.file_dialog(
      callback=self._save_layout_callback, tag="save_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale),
      default_filename="layout", default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts")
    ):
      dpg.add_file_extension(".yaml")

  def load_layout_dialog(self):
    if dpg.does_item_exist("load_layout_dialog"):
      dpg.delete_item("load_layout_dialog")
    with dpg.file_dialog(
      callback=self._load_layout_callback, tag="load_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale),
      default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts")
    ):
      dpg.add_file_extension(".yaml")

  def _save_layout_callback(self, sender, app_data):
    filepath = app_data['file_path_name']
    try:
      self.save_layout_to_yaml(filepath)
      dpg.set_value("load_status", f"Layout saved to {os.path.basename(filepath)}")
    except Exception:
      dpg.set_value("load_status", "Error saving layout")
      cloudlog.exception(f"Error saving layout to {filepath}")
    dpg.delete_item("save_layout_dialog")

  def _load_layout_callback(self, sender, app_data):
    filepath = app_data['file_path_name']
    try:
      self.load_layout_from_yaml(filepath)
      dpg.set_value("load_status", f"Layout loaded from {os.path.basename(filepath)}")
    except Exception:
      dpg.set_value("load_status", "Error loading layout")
      cloudlog.exception(f"Error loading layout from {filepath}:")
    dpg.delete_item("load_layout_dialog")

  def setup_ui(self):
    with dpg.texture_registry():
      script_dir = os.path.dirname(os.path.realpath(__file__))
      for image in ["play", "pause", "x", "split_h", "split_v", "plus"]:
        texture = dpg.load_image(os.path.join(script_dir, "assets", f"{image}.png"))
        dpg.add_static_texture(width=texture[0], height=texture[1], default_value=texture[3], tag=f"{image}_texture")

    with dpg.window(tag="Primary Window"):
      with dpg.group(horizontal=True):
        # Left panel - Data tree
        with dpg.child_window(label="Sidebar", width=int(300 * self.scale), tag="sidebar_window", border=True, resizable_x=True):
          with dpg.group(horizontal=True):
            dpg.add_input_text(tag="route_input", width=int(-75 * self.scale), hint="Enter route name...")
            dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1)
          dpg.add_text("Ready to load route", tag="load_status")
          dpg.add_separator()

          with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp):
            dpg.add_table_column(init_width_or_weight=0.5)
            dpg.add_table_column(init_width_or_weight=0.5)
            with dpg.table_row():
              dpg.add_button(label="Save Layout", callback=self.save_layout_dialog, width=-1)
              dpg.add_button(label="Load Layout", callback=self.load_layout_dialog, width=-1)
          dpg.add_separator()

          self.data_tree.create_ui("sidebar_window")

        # Right panel - Plots and timeline
        with dpg.group(tag="right_panel"):
          with dpg.child_window(label="Plot Window", border=True, height=int(-(32 + 13 * self.scale)), tag="main_plot_area"):
            self.layout_manager.create_ui("main_plot_area")

          with dpg.child_window(label="Timeline", border=True):
            with dpg.table(header_row=False):
              btn_size = int(13 * self.scale)
              dpg.add_table_column(width_fixed=True, init_width_or_weight=(btn_size + 8))  # Play button
              dpg.add_table_column(width_stretch=True)  # Timeline slider
              dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale))  # FPS counter
              with dpg.table_row():
                dpg.add_image_button(texture_tag="play_texture", tag="play_pause_button", callback=self.toggle_play_pause, width=btn_size, height=btn_size)
                dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag)
                dpg.add_text("", tag="fps_counter")
            with dpg.item_handler_registry(tag="plot_resize_handler"):
              dpg.add_item_resize_handler(callback=self.on_plot_resize)
            dpg.bind_item_handler_registry("right_panel", "plot_resize_handler")

    dpg.set_primary_window("Primary Window", True)

  def on_plot_resize(self, sender, app_data, user_data):
    self.layout_manager.on_viewport_resize()

  def load_route(self):
    route_name = dpg.get_value("route_input").strip()
    if route_name:
      dpg.set_value("load_status", "Loading route...")
      dpg.configure_item("load_button", enabled=False)
      self.data_manager.load_route(route_name)

  def toggle_play_pause(self, sender):
    self.playback_manager.toggle_play_pause()

  def timeline_drag(self, sender, app_data):
    self.playback_manager.seek(app_data)

  def update_frame(self, font):
    self.data_tree.update_frame(font)

    new_time = self.playback_manager.update_time(dpg.get_delta_time())
    if not dpg.is_item_active("timeline_slider"):
      dpg.set_value("timeline_slider", new_time)

    self.layout_manager.update_all_panels()

    dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS")

  def shutdown(self):
    self.worker_manager.shutdown()


def main(route_to_load=None, layout_to_load=None):
  dpg.create_context()

  # TODO: find better way of calculating display scaling
  try:
    w, h = next(tuple(map(int, l.split()[0].split('x'))) for l in subprocess.check_output(['xrandr']).decode().split('\n') if '*' in l)  # actual resolution
    scale = pyautogui.size()[0] / w  # scaled resolution
  except Exception:
    scale = 1

  with dpg.font_registry():
    default_font = dpg.add_font(os.path.join(BASEDIR, "selfdrive/assets/fonts/JetBrainsMono-Medium.ttf"), int(13 * scale * 2)) # 2x then scale for hidpi
  dpg.bind_font(default_font)
  dpg.set_global_font_scale(0.5)

  viewport_width, viewport_height = int(1200 * scale), int(800 * scale)
  mouse_x, mouse_y = pyautogui.position()  # TODO: find better way of creating the window where the user is (default dpg behavior annoying on multiple displays)
  dpg.create_viewport(
    title='JotPluggler', width=viewport_width, height=viewport_height, x_pos=mouse_x - viewport_width // 2, y_pos=mouse_y - viewport_height // 2
  )
  dpg.setup_dearpygui()

  controller = MainController(scale=scale)
  controller.setup_ui()

  if layout_to_load:
    try:
      controller.load_layout_from_yaml(layout_to_load)
      print(f"Loaded layout from {layout_to_load}")
    except Exception as e:
      print(f"Failed to load layout from {layout_to_load}: {e}")
      cloudlog.exception(f"Error loading layout from {layout_to_load}")

  if route_to_load:
    dpg.set_value("route_input", route_to_load)
    controller.load_route()

  dpg.show_viewport()

  # Main loop
  try:
    while dpg.is_dearpygui_running():
      controller.update_frame(default_font)
      dpg.render_dearpygui_frame()
  finally:
    controller.shutdown()
    dpg.destroy_context()

if __name__ == "__main__":
  parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.")
  parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one")
  parser.add_argument("--layout", type=str, help="Path to YAML layout file to load on startup")
  parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.")
  args = parser.parse_args()
  route = DEMO_ROUTE if args.demo else args.route
  main(route_to_load=route, layout_to_load=args.layout)
